이미지 보정 시스템, 병목은 CPU
정확히 문제가 무엇이었는가.
필자가 운영 중인 서비스는 보험청구를 위한 이미지를 보험사에 발송해야 한다.
병원과 직접 연동되어 있는 경우에는 병원 데이터를 기반으로 PDF를 생성하여 발송하면 된다. 하지만 모든 병원이 연동되어 있는 것은 아니다. 연동되지 않은 병원의 경우 사용자가 직접 서류를 촬영해 업로드하고, 서비스는 이 이미지를 보험사로 전달해야 한다.
"문제는 여기서 발생했다'
사용자가 촬영한 서류 이미지는 모두 동일한 품질을 가지지 않는다. 촬영 환경은 사용자마다 다르다. 조도, 각도, 스마트폰 기종, 카메라 성능, 흔들림, 그림자 등 모든 상태가 다 다르다. 이러한 이유로 인해 이미지 압축을 하긴 하지만, 이 압축 프로세스마저 외부 플랫폼에 의존하고 있었다.
이 과정에서 크게 두 가지 문제가 발생했다.
첫 번째, 문서 품질이 저하되어 보험사에서 판독이 어려운 경우가 발생했다. 이 경우 이미지를 다시 요청하거나 원본에 가까운 파일을 별도로 전달해야했고, 결과적으로 추가적인 운영비용이 발생했다.
두 번째, 이미지 발송 비용 문제가 있었다. 이러한 이미지를 발송 시 외부 플랫폼에 의존하여 압축과 발송을 동시에 위임하는데, 이 때 이미지 1장의 크기가 특정 용량 기준을 넘으면 1장이라도 여러 장으로 계산될 수 있었다.
정리하면 문제는 단순히 "이미지가 크다"거나 "화질이 좋지 않다"가 아니었다.
사용자마다 제각각인 촬영 이미지를 보험사가 판독하기 수월한 수준으로 보정하면서도 발송 비용을 낮출 수 있도록 이미지 크기를 제어해야 했다.
이 문제는 3월부터 인지하여 지속적인 개선 방향을 제시했으나, 당시에는 우선순위가 높은 업무들이 몰아닥치며 바로 착수하지 못했다.
급한 업무들이 마무리되고, 틈틈히 검토해둔 이미지 전처리 방향을 기반으로 별도의 이미지 보정 시스템을 개발하게 되었다.
FAX 와 TIFF
FAX는 전기 통신이 발전하던 시기에, 사람들은 글뿐 아니라 문서와 그림 자체를 멀리 보내는 방법을 고민했다. 이 때 한 엔지니어는 문서를 선과 점의 신호로 분해하여 전송하고, 수신 측에서 이를 다시 복원하는 아이디어가 등장했다.
이 아이디어는 전화가 발명되고 전화선 기반의 팩스 장비로 발전했는데, 이 때 문서를 검은 점과 흰 점으로 이루어진 1bit 이미지에 가깝게 단순화 하면, 전화망과 같은 제한된 통신 환경에서도 문서를 효과적으로 송수신 할 수 있었다.
여기서 필자는 "아름다운 이미지 전송"이 아니라 "효율적인 문서 전달"이 목적이라 생각되었다.
조금 더 자세히 들어가고 싶지만, 이 글의 목적은 FAX의 역사가 아니라 이미지 보정 시스템 개발 과정이다. 따라서 여기서는 빠르게 끊고, 구현 관점에서 중요한 포맷 이야기로 넘어가려 한다.
전통적인 문서 FAX는 우리가 흔히 말하는 '흑백문서'와 잘 맞는다. 이와 잘 맞는 이미지 포맷 중 하나가 TIFF다. TIFF는 스캔 문서나 흑백 문서 이미지를 저장하기에 적합한 포맷이며, 내부적으로 Group 3 / Group 4 Fax 압축을 통해 문서 이미지를 효율적으로 다룰 수 있다.
그래서 원본 JPG/PNG 이미지를 보정한 뒤 TIFF로 변환하는 방향을 검토했다.
이유는 명확했다. 사용자마다 다른 촬영본을 FAX 발송 플랫폼에 압축을 위임할 경우 품질과 비용을 예상할 수 없게된다. 하지만, 직접 보정을 하게 된다면 어느 정도 예측 가능한 범위로 줄일 수 있으리라 판단했다.
즉, 이미지를 보기 좋게 만들기 보다 보험사가 판독 가능한 수준의 문서로 압축하고, FAX 발송 과정에서 불필요한 비용이 증가하지 않도록 제어하는것이었다.
품질 개선 스토리
기존 서류 접수부터 발송까지 담당하는 서비스의 각 pod 의 성능은 1Core/3Gi 의 성능이었다.
이미지 보정은 파일 읽기/쓰기보다 픽셀 단위 연산이 많기 때문에 CPU에 병목이 생기리라 생각해 현재 서비스의 수준을 검토해 보았는데, 청구부터 이미지 보정까지 담당하기에는 서비스의 성능이 썩 좋지는 않았다.
하지만, 최적화를 한다면 해당 성능에서도 문제가 없으리라 생각해 일단 간단하게 이미지 처리 시스템을 만들어보기로 했다.
AVIF to TIFF
처음에는 이미지 압축에 대해서 고민을 많이했다.
JPG/PNG Decode
-> AVIF Encode
-> AVIF Decode
-> TIFF Encode이러한 파이프라인을 고민했으나 결국 CPU Bound 가 병목이라 생각했는데, 중간 손실 압축을 끼워서 한번 더 변환을 한다는것은 병목 지점을 더 늘리는것이라 생각했다.
AVIF 로 변환하며 이미지 자체의 용량을 낮추고, 이를 TIFF로 변환하는것은 충분히 매력적인 포인트였으나, 이미 이미지 보정 자체가 CPU Bound 작업에 가깝다고 판단한 상황에서, AVIF Encode/Decode 를 중간에 추가하는것은 오히려 처리 비용을 늘리는 선택이라 생각했다.
BufferedImage/ImageIO
단순히 이미지를 읽고 픽셀로 변환해 다시 저장하는 정도라면 충분히 구현이 가능해보여 Java 표준 API 인 BufferedImage 와 ImageIO 를 검토해 보았다.
이를 검토한 이유는 BufferedImage.TYPE_BYTE_BINARY를 사용할 수 있었기 때문이다.
BufferedImage image = new BufferedImage(
width,
height,
BufferedImage.TYPE_BYTE_BINARY
)이 코드는 개념적으로 단순했다.
원본 이미지를 읽고, 각 픽셀의 밝기값을 기준으로 흰색 혹은 검은색으로 변환한 뒤, 1-bit 에 가까운 흑백 이미지로 저장하는 흐름이다.
하지만, 실제로 구현해보니 애매한 부분이 있었다.
"효율적인 문서 전달" 이라는 목적을 이루기에는 많이 부족했다.
사용자가 촬영한 이미지는 모두 조도와 그림자가 모두 다르기에 전체 이미지에 하나의 threshold를 적용하면 특정 영역의 글자가 지워지거나 노이즈가 과하게 생기는 경우가 생겨 오히려 문서 판독이 어려웠다.
이를 개선하기 위해서는 이를 보정하기 위한 알고리즘을 직접 고도화 해야했는데, 현재 AI가 발전했다고 하더라도 내가 이해하지 못하는 코드는 누구에게 설명하기도 힘들고, 나중에 개선이 필요할 때 개선을 하기 힘들거같아 다른 방향을 검토하게 되었다.
adaptive threshold
이러한 문제가 있기에 threshold 를 계속 변경해가며 약 100장정도의 이미지를 대상으로 테스트를 해보았다.
이 과정에서 대부분 아무리 값을 조정해도 진전이 없어 여러 방안을 모색하던 중 Adaptive Threshold라는 키워드를 발견하게 되었다.
threshold -> 이미지 전체에 하나의 기준값 적용
adaptive threshold -> 주변 픽셀 값을 기준으로 영역별 기준값 계산이번 이미지 보정 시스템을 개발하며 알게 된 키워드인데,
일반 threshold 는 고정된 임계값으로 보정하기 때문에 식별 가능한 문서를 만들기에 부적합했고, adaptive threshold를 이용해 주변 픽셀 값을 기준으로 영역별로 계산을 하면 된다는것을 알게되었다.
OpenCV / JavaCV
그러면서 자연스레 검토하게 된 라이브러리가 OpenCV 이다.
~~이는 아주 예전에 잠깐 들어본 라이브러리인데, 여기서 만나게 될 줄 몰랐다.~~
내가 필요했던 것은 단순히 이미지를 흑백으로 바꾸는것이 아니라, 사용자가 서로 다른 환경에서 촬영한 문서를 최대한 판독 가능한 형태로 만드는것인데, OpenCV는 threshold와 grayscale, blur 같은 연산을 모두 제공하고 있어 가장 알맞은 선택이라 생각했다.
여기서 Python 을 이용해서 별도 서비스로 뺄까도 고민했다.
하지만 당시에는 청구 파이프라인에 바로 이식해야겠다는 생각이 더 컸고, 기존 서비스가 Java/Spring 기반이었기 때문에 Java 에서 OpenCV 를 사용할 수 있는 방법을 찾았다.
그 과정에서 JavaCV를 적용하게 되었다.
최초에는 아래의 흐름대로 파이프라인을 작성했다
원본 JPG/PNG
-> grayscale 변환
-> blur 처리
-> adaptive threshold
-> binary image 저장구현 방향은 Mat 을 기반으로 가져갔다.
Mat gray = imread(input.toString());
Mat binary = new Mat();
adaptiveThreshold(
gray,
binary,
255,
ADAPTIVE_THRESH_GAUSSIAN_C,
THRESH_BINARY,
blockSize,
c
);여기서 OpenCV에대해 알아보며 새롭게 고려해야할 부분이 생겼는데,
BufferedImage는 JVM 객체 중심으로 다루지만, OpenCV의 Mat은 native memory를 사용한다. 즉, JVM heap 만 보고 메모리 사용량을 판단하면 실제 컨테이너 메모리 사용량을 놓칠 수 있다.
여기에 기존 pod 의 리소스가 1Core/3Gi 였기 때문에 이 부분을 무시할 수도 없었다.
이미지 보정은 단순 I/O 작업이라기보다 픽셀 단위 연산이 많은 CPU-bound 작업에 가깝다. 여기에 OpenCV 의 Mat 이 native memory 를 사용하기 때문에, CPU 사용량뿐 아니라 native memory 관리까지 함께 고려해야 했다.
그래서 OpenCV 를 적용하면서 native resource 를 명시적으로 관리할 방법도 함께 검토하였다.
PointerScope
OpenCV는 C++ 기반의 라이브러리 이기 때문에 Java에서 사용하더라도 내부적으로는 native memory를 사용한다.
이미지 처리마다 생성되는 Mat 객체는 내부적으로 JVM heap 외의 native memory를 사용하기 때문에 GC가 모든 리소스를 적절한 시점에 정리해줄 것이라 기대하면 위험할 수 있다.
요청 수가 적을 경우 문제가 드러나지 않을 수 있으나, 여러 장의 이미지를 처리하게 되면 native memory 사용량이 빠르게 증가하며 서버가 다운될 위험이 있다.
그래서 PointerScope를 사용해 요청 단위로 native memory의 생명주기를 관리해주었다.
try (PointerScope scope = new PointerScope()) {
Mat gray = imread(input.toString(), IMREAD_GRAYSCALE);
Mat binary = new Mat();
adaptiveThreshold(
gray,
binary,
255,
ADAPTIVE_THRESH_GAUSSIAN_C,
THRESH_BINARY,
blockSize,
c
);
}PointerScope는 네이티브 C++ 메모리 관리를 Java의 Garbage Collector에만 의존하지 않고, 투명하고 결정론적인 방식으로 처리하는 도구이다.
하나씩 제거하기
우선, 어느 정도 구현을 마무리하고, K6를 이용해 성능측정을 해 보았다.
M4맥북에서 Docker를 이용해 Ubuntu Server, 1Core, 3Gi 환경을 구성했다.
실제 개발환경보다 core 효율이 다르기에 정확한 측정이 되진 않겠지만, 당시 개발서버를 다른팀에서 사용하고있어 사용할 수 없어 로컬에서 테스트를 진행했다.
운영 성능을 측정하기 위한 테스트보다, 현재의 구현이 제한된 리소스 안에서 어느 정도 버틸 수 있는지 확인하기 위한 목적이었다.
첫 측정
특정 일의 평균 청구 건은 500건 이었다.
청구 시 worst-case 는 한 건당 15장의 이미지를 전달하는 방식인데, 이 기준으로 보면 일 평균 처리 이미지는 7,500장이다.
테스트는 worst-case인 요청당 15장의 이미지를 전달하는 방식으로 구성했다.
부하테스트는 최대 20 VUs, 총 5분 30초 ramping으로 수행했다.
http_req_duration: avg=4.58s med=2.81s max=14.43s p(90)=11.23s p(95)=12.18s
http_reqs: 368
http_req_failed: 0.00%하지만 문제는 응답시간이었다.
공식적으로 합의된 SLO는 아니었지만, 개인적인 목표 응답시간은 1초 이내였다.
이유는 단순했다.
이미지 보정은 최종적으로 비동기 처리로 분리할 수 있더라도, 이 구간에서 처리 시간이 길어지면 이후 PDF 병합과 FAX 발송 단계까지 함께 밀릴 수 있다고 생각했다.
즉, 사용자가 직접 기다리는 API가 아니더라도 이 작업이 느려지면 전체 청구 파이프라인의 처리 지연으로 이어질 수 있다.
그래서 최소한 이미지 보정 단계 자체는 병목이 되지 않도록, 내부 검토 기준으로 1초 이내를 목표로 잡았다.
그런데 측정 결과는 처참했다. avg는 4초를 넘었고, P90/P95는 11초를 넘어서고 있었다.
368건의 요청을 처리한 시점 5,000장 이상의 이미지를 처리한 셈이라 처리량 자체는 부족하지 않았으나, CPU 사용량이 100%를 찍고 목표 응답시간에 도달하지 못했다.
"처리된다"와 "안정적으로 운영할 수 있다"는 다른 문제였다.
이 결과를 보고, 불필요한 CPU 연산을 식별하고 하나씩 제거하기로 했다.
1차 개선 - grayscale decode
첫 번째로 제거한 것은 컬러 decode 과정이었다.
최종 목표는 흑백 문서 이미지였다. 그렇다면 원본 이미지를 컬러로 읽고, 다시 grayscale 로 변환하는 과정은 불필요하다고 판단했다.
그래서 처음부터 IMREAD_GRAYSCALE 로 이미지를 읽도록 변경했다.
Mat gray = imread(input.toString(), IMREAD_GRAYSCALE);기존 흐름은 아래와 같았다.
JPG/PNG 컬러 decode
-> grayscale 변환
-> threshold변경 후 흐름은 더 단순해졌다.
JPG/PNG grayscale decode
-> threshold작은 변경처럼 보일 수 있지만, 이미지 처리는 결국 픽셀 수만큼 반복되는 연산이다.
청구 한 건에 최대 15장의 이미지가 포함될 수 있다는 점을 생각하면, 이미지마다 반복되는 불필요한 변환은 제거하는 것이 맞다고 판단했다.
1차 개선 결과는 다음과 같았다.
http_req_duration: avg=3.9s med=2.4s max=12.3s p(90)=10.05s p(95)=11.06s
http_reqs: 416
http_req_failed: 0.00%완료 요청 수는 368건에서 416건으로 증가했다. 평균 응답시간은 4.58초에서 3.9초로 줄었고, p95도 12.18초에서 11.06초로 내려갔다.
아직 개인적으로 잡은 목표 응답시간인 1초에는 한참 부족했다. 하지만 적어도 불필요한 컬러 처리 비용을 제거하는 방향은 맞다고 판단했다.
2차 개선 - fallback 시 이미지 재읽기 제거
다음으로 본 것은 fallback 처리였다.
이미지 보정 결과 파일 크기가 기준을 넘으면 다른 profile 로 다시 처리하는 fallback 흐름이 있었다. 처음에는 fallback 이 발생하면 이미지를 다시 읽고, 다시 보정하는 방식이었다.
기본 profile 처리
-> 파일 크기 확인
-> 기준 초과
-> 이미지 다시 읽기
-> fallback profile 처리동작 자체는 문제 없었다. 하지만 다시 생각해보면 원본 이미지를 다시 읽을 필요는 없었다.
fallback 은 원본 이미지가 바뀌는 것이 아니라, threshold 에 사용하는 profile 만 바뀌는 것이다. 그렇다면 이미 읽어둔 grayscale Mat 을 재사용하면 된다.
그래서 흐름을 아래처럼 변경했다.
이미지를 grayscale Mat 으로 읽음
-> 기본 profile 처리
-> 파일 크기 확인
-> 기준 초과
-> 같은 gray Mat 으로 fallback profile 재처리2차 개선 결과는 다음과 같았다.
http_req_duration: avg=3.92s med=2.49s max=12.29s p(90)=9.85s p(95)=10.87s
http_reqs: 417
http_req_failed: 0.00%수치상으로 큰 변화는 없었다.
완료 요청 수는 416건에서 417건으로 거의 동일했고, 평균 응답시간도 3.9초에서 3.92초로 오히려 약간 증가했다. 하지만 p90은 10.05초에서 9.85초로, p95는 11.06초에서 10.87초로 소폭 감소했다.
극적인 개선은 아니었다. 다만 같은 이미지를 다시 읽는 불필요한 decode 를 제거했다는 점에서 구조적으로는 더 나은 방향이라고 판단했다.
3차 개선 - BufferedImage 변환 제거
다음으로 제거한 것은 BufferedImage 변환 과정이었다.
초기에는 OpenCV 로 처리한 Mat 결과를 다시 BufferedImage.TYPE_BYTE_BINARY 로 변환한 뒤 ImageIO.write 로 저장하는 방식도 검토했다.
Mat binary
-> BufferedImage(TYPE_BYTE_BINARY)
-> ImageIO.write이 방식은 파일 크기 측면에서는 장점이 있었다.
TYPE_BYTE_BINARY 를 사용하면 1-bit 에 가까운 흑백 이미지로 저장할 수 있기 때문이다.
하지만 성능 관점에서는 불필요한 변환 비용이 있었다.
이미 OpenCV 의 Mat 기반으로 이미지를 처리했는데, 저장을 위해 다시 JVM 객체인 BufferedImage 로 변환하고 있었고, native 영역에서 처리한 이미지를 다시 Java 객체로 끌어올리고, 픽셀을 복사한 뒤, ImageIO.write 로 저장하는 흐름이었다.
CPU가 이미 100%까지 올라가는 상황에서 이 변환 과정은 제거해야 할 대상으로 보였다.
그래서 저장 방식도 OpenCV 쪽으로 맞췄다.
imwrite(output.toString(), binary);변경 후 흐름은 단순해졌다.
Mat binary
-> imwrite3차 개선 결과는 다음과 같았다.
http_req_duration: avg=3.31s med=1.99s max=11.18s p(90)=8.34s p(95)=9.31s
http_reqs: 469
http_req_failed: 0.00%이 변경은 꽤 의미가 있었다.
완료 요청 수는 417건에서 469건으로 증가했다.
평균 응답시간은 3.92초에서 3.31초로 줄었고, p95는 10.87초에서 9.31초로 내려갔다.
특히 p90도 9.85초에서 8.34초로 줄었다.
즉, 단순히 일부 빠른 요청만 좋아진 것이 아니라, 부하 상황에서 느린 요청의 분포도 같이 내려갔다고 볼 수 있었다.
다만, 트레이드오프도 있었다.
OpenCV 의 imwrite(binary) 는 1-bit PNG 저장을 보장하지 않는다.
0/255 로 이루어진 binary image 라도 실제 저장 결과는 8-bit grayscale PNG 가 될 수 있고, 이 경우 파일 크기가 예상보다 커질 수 있다.
즉, 이 변경은 파일 크기보다 처리 성능을 우선한 선택이었다. 당시 병목은 파일 크기보다 CPU 사용량과 응답시간에 있었기 때문에, 우선은 변환 비용을 제거하는 쪽이 맞다고 판단했다.
4차 개선 - Gaussian 에서 Mean 으로 변경
마지막으로 adaptive threshold 방식을 변경했다.
처음에는 ADAPTIVE_THRESH_GAUSSIAN_C 를 사용했다.
Gaussian 방식은 주변 픽셀에 가중치를 두고 threshold 를 계산하면 품질 측면에서는 자연스러운 결과를 만들 수 있지만, 계산량이 더 크다.
하지만 이번 작업의 목적은 사진처럼 자연스러운 이미지를 만드는 것이 아니었다.
보험사가 판독 가능한 문서 이미지를 만드는 것이 목적이기에 글자와 배경이 충분히 분리된다면 더 단순한 연산을 사용하는 것이 맞다고 판단해 ADAPTIVE_THRESH_MEAN_C로 방식을 변경했다.
Mean 방식은 주변 픽셀의 평균값을 기준으로 threshold 를 계산하는데, 이 방식은 Gaussian 방식보다 단순하고, 현재처럼 CPU-bound 가 명확한 상황에서는 더 적합할 수 있다고 봤다.
4차 개선 결과는 다음과 같았다.
http_req_duration: avg=2.79s med=2.06s max=8.79s p(90)=6.54s p(95)=7.26s
http_reqs: 532
http_req_failed: 0.00%가장 의미 있는 개선이었다.
완료 요청 수는 469건에서 532건으로 증가했다.
평균 응답시간은 3.31초에서 2.79초로 줄었고, p95는 9.31초에서 7.26초까지 내려갔다.
최대 응답시간도 11.18초에서 8.79초로 줄었다.
평균뿐 아니라 p90, p95, max 가 모두 내려갔다는 점에서 의미가 있었다.
처음 측정 결과와 비교하면 변화는 더 명확했다.
http_reqs: 368 -> 532
avg: 4.58s -> 2.79s
p90: 11.23s -> 6.54s
p95: 12.18s -> 7.26s
max: 14.43s -> 8.79s처리 요청 수는 약 44.6% 증가했고, 평균 응답시간은 약 39.1% 감소했다. p90은 약 41.8%, p95는 약 40.4% 감소했다.
수치만 보면 꽤 많이 개선되었다.
하지만 이 결과를 보고 기존 청구 서비스 안에서 그대로 처리해도 된다고 판단하지는 않았다. 오히려 반대였다.
이미지 처리 성능은 많이 개선되었지만, 이미지 보정은 여전히 CPU-bound 작업이었다.
부하가 걸리면 CPU 사용률이 100%까지 올라갔고, 이 작업이 청구 프로세스와 같은 pod 안에서 실행되면 최악의 경우 청구와 관련된 모든 응답시간이 함께 지연될 수 있다고 예상했다.
결국 문제는 “이미지 보정 API 하나가 얼마나 빨라졌는가”가 아니었다. 이미지 보정 작업이 기존 청구 서버의 CPU를 잠식했을 때, 청구 접수, PDF 병합, FAX 발송 등 전체 파이프라인에 영향을 줄 수 있다는 점이 더 큰 문제였다.
결론
해당 내용을 팀장님께 에스컬레이션했고, CTO 및 DevOps 팀과 협의한 결과 이미지 보정 기능은 별도의 VM을 만들어 Server to Server 방식으로 처리하기로 결정되었다.
현재는 VM 서버를 구성하면서 핵심 기능을 유지한 채 코드를 다시 작성하고 있다. 이 과정도 어느 정도 정리되면 별도로 기록해보려 한다.
이번 작업은 단순히 이미지를 보정하는 일이 아니었다.
여러 트레이드오프를 고민하며 직접 측정하고, 제거하고, 다시 판단하는 과정이었다.
특히 병목이 읽기인지, 쓰기인지, CPU인지, Network인지 구분하는 것이 중요하다는 점을 체감했다.
그리고 그 병목이 기존 시스템에 어떤 영향을 줄 수 있는지까지 판단해야 비로소 운영 가능한 개선이 된다는 것도 다시 깨달았다.